<?php

/*

Desktopd
Copyright (C) 2015  Desktopd Developer(s)

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.

https://www.gnu.org/licenses/agpl.html

*/


namespace Desktopd\Desktopd;

use Desktopd\PlatformTools\Log;


/**
    HTTP downloader (depends on: cURL)
*/

class Downloader {
    const PRODUCT_NAME = 'Desktopd';
    const VERSION = '2.0';
    
    // may include a URI
    const COMMENT = 'Fork me on: https://notabug.org/desktopd/desktopd';
    //const COMMENT = 'HTTPRequest';
    
    // %1$s -> the version of Desktopd
    // %2$s -> the version of cURL
    // %3$s -> comments (may include a URI)
    // %4$s -> the name of this program
    const UA_NORMAL = 'Mozilla/5.0 (compatible; %4$s/%1$s; %3$s)';
    const UA_NOVERSION = 'Mozilla/5.0 (compatible; %4$s; %3$s)';
    const UA_NOCOMMENTS = 'Mozilla/5.0 (compatible; %4$s/%1$s)';
    const UA_NOVERSION_NOCOMMENTS = 'Mozilla/5.0 (compatible; %4$s)';
    const UA_LIBCURL = 'Mozilla/5.0 (compatible; %4$s/%1$s; %3$s) libcurl/%2$s';
    const UA_LIBCURL_NOCOMMENTS = 'Mozilla/5.0 (compatible; %4$s/%1$s) libcurl/%2$s';
    const UA_LIBCURL_NOVERSION_NOCOMMENTS = 'Mozilla/5.0 (compatible; %4$s) libcurl';
    const UA_CURL = 'curl/%2$s';
    const UA_SHORT = '%4$s/%1$s';
    const UA_SHORT_NOVERSION = '%4$s';
    
    const UA_DEFAULT = self::UA_LIBCURL;
    
    
    /*
        List of TLS ciphers in the preferred order
        https://wiki.mozilla.org/Security/Server_Side_TLS
        (last modified on 4 March 2015, at 06:46)
        
        Note: Added TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
        and TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
        at the first of each list (like Chromium)
    */
    const TLS_CIPHERS_SECURE = 'ECDHE-RSA-CHACHA20-POLY1305-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK';
    
    const TLS_CIPHERS_COMPAT = 'ECDHE-RSA-CHACHA20-POLY1305-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
    
    const TLS_CIPHERS_DEFAULT = self::TLS_CIPHERS_COMPAT;
    
    
    protected $userAgent = '';
    protected $ciphers = self::TLS_CIPHERS_DEFAULT;
    
    protected $share = false; // cURL share resource (may not be supported)
    
    // used for raw request (requestResource)
    protected $lastInfo = array();
    protected $lastError = '';
    
    protected $recentCiphersByHost = array();
    protected $lastCipher = null;
    protected $lastHost = '';
    
    
    public function __construct ($userAgent = self::UA_DEFAULT, $ciphers = self::TLS_CIPHERS_DEFAULT) {
        if (!function_exists('curl_version')) {
            throw new \RuntimeException('cURL is not available');
        }
        
        $this->setUserAgent($userAgent);
        $this->setCiphers($ciphers);
        
        try {
            if (!function_exists('curl_share_init')) {
                throw new \RuntimeException('curl_share_init: not available');
            }
            
            $share = curl_share_init();
            if (!is_resource($share)) {
                throw new \RuntimeException('curl_share_init: failed');
            }
            
            curl_share_setopt($share, CURLSHOPT_SHARE, CURL_LOCK_DATA_COOKIE);
            curl_share_setopt($share, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);
            curl_share_setopt($share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);
            
        } catch (\RuntimeException $e) {
            $share = false;
        }
        
        $this->share = $share;
    }
    
    public function setCiphers ($ciphers = self::TLS_CIPHERS_DEFAULT) {
        return $this->ciphers = $ciphers;
    }
    
    public function getCiphers () {
        return $this->ciphers;
    }
    
    public function setUserAgent ($userAgent = self::UA_DEFAULT) {
        $curlVersion = curl_version()['version'];
        return $this->userAgent = sprintf($userAgent, self::VERSION, $curlVersion, self::COMMENT, self::PRODUCT_NAME);
    }
    
    public function getUserAgent () {
        return $this->userAgent;
    }
    
    public function checkURI ($uri) {
        // no embedded HTTP auth
        // TODO: make IPv6 address check more strict
        if (!preg_match('~^https?://(?:\[[0-9a-fA-F:]+\]|[^./:@]+?(?:\.[^./:@]+?)*)(?:\:[1-9][0-9]{0,5})?(?:/.*)?$~', $uri)) {
            // Of all URIs only HTTP(S) URLs are supported
            return false;
        }
        
        return true;
    }
    
    public function extractHost ($uri) {
        if (preg_match('~^(?:\w+?:)?//(?:[^@:/]+(?:\:[^@:/]+)?@)?([^/]+)(?:/.*)?$~', $uri, $matches)) {
            return $matches[1];
        }
        
        return '';
    }
    
    protected function probeHost ($uri) {
        $host = $this->extractHost($uri);
        $this->lastHost = $host;
        if (isset($this->recentCiphersByHost[$host])) {
            $this->lastCipher = $this->recentCiphersByHost[$host];
        } else {
            $this->lastCipher = $this->checkHTTPS("https://$host/");
            $this->recentCiphersByHost[$host] = $this->lastCipher;
        }
    }
    
    public function getCipherByHost ($host) {
        return isset($this->recentCiphersByHost[$host]) ? $this->recentCiphersByHost[$host] : null;
    }
    
    public function getLastCipher () {
        return $this->lastCipher;
    }
    
    public function getLastHost () {
        return $this->lastHost;
    }
    
    public function getLastError () {
        return $this->lastError;
    }
    
    public function checkHTTPS ($uri) {
        if (!function_exists('proc_open')) {
            return false;
        }
        
        if (false !== strpos(ini_get('disable_functions'), 'proc_open')) {
            return false;
        }
        
        if (!preg_match('~^https://.+$~', $uri)) {
            // Not HTTPS
            return false;
        }
        
        $command = sprintf(
            "curl --head --verbose --ciphers %s --user-agent %s --url %s"
            , escapeshellarg($this->ciphers)
            , escapeshellarg($this->getUserAgent())
            , escapeshellarg($uri)
        );
        
        $fdSpec = array(
            0 => array('pipe', 'r')
            , 1 => array('pipe', 'w')
            , 2 => array('pipe', 'w')
        );
        
        $pipes = array();
        $env = array();
        
        $process = proc_open($command, $fdSpec, $pipes, null, $env);
        if (!is_resource($process)) {
            return false;
        }
        
        fclose($pipes[0]);
        while ($line = fgets($pipes[2])) {
            if (preg_match('~^\* (?:SSL|TLS) connection using (\S+?) / (\S+)~', $line, $matches)) {
                fclose($pipes[1]);
                fclose($pipes[2]);
                proc_close($process);
                return array($matches[1], $matches[2]);
            }
        }
        
        fclose($pipes[1]);
        fclose($pipes[2]);
        proc_close($process);
        
        return array();
    }
    
    // request and return response
    public function requestResource ($uri, $failOnErrorCodes = true) {
        if (!$this->checkURI($uri)) {
            echo "Check failed!\n";
            return false;
        }
        
        $uri = $this->encodeURI($uri);
        
        $curl = curl_init($uri);
        if (!is_resource($curl)) {
            echo "curl_init() failed!\n";
            return false;
        }
        
        $this->probeHost($uri);
        
        curl_setopt($curl, CURLOPT_AUTOREFERER, true);
        curl_setopt($curl, CURLOPT_BINARYTRANSFER, true);
        curl_setopt($curl, CURLOPT_VERBOSE, true);
        if (defined('CURLOPT_CERTINFO')) {
            curl_setopt($curl, CURLOPT_CERTINFO, true);
        }
        curl_setopt($curl, CURLOPT_DNS_USE_GLOBAL_CACHE, false);
        curl_setopt($curl, CURLOPT_FAILONERROR, (bool) $failOnErrorCodes);
        curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($curl, CURLOPT_MAXREDIRS, 10);
        curl_setopt($curl, CURLINFO_HEADER_OUT, true); // request header
        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
        curl_setopt($curl, CURLOPT_ENCODING, "gzip, deflate");
        //curl_setopt($curl, CURLOPT_INTERFACE, '192.168.1.10');
        curl_setopt($curl, CURLOPT_SSL_CIPHER_LIST, $this->ciphers);
        curl_setopt($curl, CURLOPT_USERAGENT, $this->userAgent);
        
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        
        if (is_resource($this->share)) {
            curl_setopt($curl, CURLOPT_SHARE, $this->share);
        }
        
        $data = curl_exec($curl);
        $info = curl_getinfo($curl);
        if (is_array($info)) {
            $this->lastInfo = $info;
        }
        
        if (curl_errno($curl)) {
            $this->lastError = curl_error($curl);
            curl_close($curl);
            return false;
        }
        
        curl_close($curl);
        return $data;
    }
    
    public function __destruct () {
        if (is_resource($this->share)) {
            curl_share_close($this->share);
        }
    }
    
    public function encodeURI ($uri) {
        // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI
        // modified to be IPv6-safe
        
        $unescaped = array(
            '%2D'=>'-','%5F'=>'_','%2E'=>'.','%21'=>'!', '%7E'=>'~',
            '%2A'=>'*', '%27'=>"'", '%28'=>'(', '%29'=>')'
        );
        $reserved = array(
            '%3B'=>';','%2C'=>',','%2F'=>'/','%3F'=>'?','%3A'=>':',
            '%40'=>'@','%26'=>'&','%3D'=>'=','%2B'=>'+','%24'=>'$', '%25' => '%'
        );
        $score = array(
            '%23'=>'#'
        );
        
        $ipv6 = array(
            '%5B' => '[', '%5D' => ']'
        );
        
        return strtr(rawurlencode($uri), array_merge($reserved, $unescaped, $score, $ipv6));
    }
    
    // Does not double-encode a '%'
    public function encodeQuery ($query) {
        return strtr(rawurlencode($query), array('%25' => '%'));
    }
    
    public function encodeURIComponent ($uri) {
        return rawurlencode($uri);
    }
    
}


// vim: ts=4 et ai

